fix(driver-memory): analytics dateRange resolves on the UTC calendar, not the process-local one (#15825) - #16047
Conversation
…r, not the process-local one (#15825) `parseDateRangeString()` built its window on the LOCAL calendar and rendered it as UTC. Two independent defects, both repaired here: 1. The boundary was `new Date(y, m, d)` — LOCAL midnight — rendered by `toISOString()` as UTC. Wrong on every day of the year in every non-UTC process, no DST transition needed: measured, the `'today'` bucket ran from the previous 16:00Z at `Asia/Shanghai` and from 07:00Z at `America/Los_Angeles`. 2. The `last N ...` legs did day/week/month/year arithmetic on the LOCAL calendar and rendered it on the UTC one, so the window start slipped an hour across a DST transition — and a whole day on the `month` leg. Neither repair fixes the other, so each is pinned by its own file and each was ablated separately. The DST file cannot go red at `TZ=UTC`, where the two spellings are indistinguishable — which is why CI never reddened on this. UTC is the target calendar because the rest of the platform already resolves a bare date to the UTC day (`{today}`, `{TODAY()}`), and disagreeing with it let one deployment answer the same question two ways. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ARYe3yQTQCUFm5qPYNgKaJ
📓 Docs Drift Check1 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to list — not a clean bill of health. This check sees only pages that NAME a derived anchor: one that documents this change in prose, or enumerates it in an authoring dialect, names none and stays invisible to it on every run. What this run could not see
Coarse fallback — 8 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin faadf3dbc7c68435b224d907e3e131aecb98cdff && git checkout faadf3dbc7c68435b224d907e3e131aecb98cdff
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 2e357650306335f7fdb8939d16edce4c96b48831 cb1cb6e54f8531afb8d038e84370ffdc4e4cc0ca && git checkout -B drift-repro 2e357650306335f7fdb8939d16edce4c96b48831 && git merge --no-ff cb1cb6e54f8531afb8d038e84370ffdc4e4cc0ca
node scripts/docs-audit/affected-docs.mjs --json 2e357650306335f7fdb8939d16edce4c96b48831 |
Fixes #15825
MemoryAnalyticsService.query()lowers a stringdateRangethroughparseDateRangeString(), and that function built its window on the local calendar and rendered it as UTC. There were two independent defects, and each is repaired and pinned separately here.Defect 1 — the window boundary was LOCAL midnight
new Date(y, m, d)constructs local midnight;toISOString()renders that instant in UTC. So in any process not sitting at UTC, the'today'bucket was the local day expressed as a UTC range. ⛔ Wrong on every day of the year, with no DST transition needed. Reproduced before touching anything, clock at2026-09-05T20:51Z, by transcribing the function verbatim fromorigin/main:TZ'today'window producedUTC2026-09-05T00:00Z→2026-09-06T00:00ZAsia/Shanghai2026-09-05T16:00Z→2026-09-06T16:00Z2026-09-05T00:00Z→2026-09-06T00:00ZAmerica/Los_Angeles2026-09-05T07:00Z→2026-09-06T07:00Z2026-09-05T00:00Z→2026-09-06T00:00ZEurope/Berlin2026-09-04T22:00Z→2026-09-05T22:00Z2026-09-05T00:00Z→2026-09-06T00:00ZAsia/Kolkata2026-09-05T18:30Z→2026-09-06T18:30Z2026-09-05T00:00Z→2026-09-06T00:00Z(The card predicted
08:00Zfor Los Angeles;07:00Zis the same defect in PDT rather than PST — both are pinned,2026-09-05and2026-01-15.)Defect 2 — the
last N ...legs mixed two calendarssetDate(getDate() - n)is local arithmetic andtoISOString()is a UTC rendering.setDatepreserves wall-clock time, so the instant movesn × 24honly while every local day in the window is 24 hours long; across a DST transition it moves 23h or 25h and the window start slips an hour.setMonth/setFullYearare the same class — and can move the answer by a whole day: atAmerica/New_Yorkwith the clock at2026-01-01T12:00Z,last 1 monthstarted at2025-12-02T00:00Zinstead of2025-12-01T00:00Z.⭐ The two are independent — demonstrated, not asserted
⛔
setDate→setUTCDatedoes not fix defect 1, andDate.UTCalone does not fix defect 2. Each half was ablated on its own, in one shell, with the mutation proven on disk by anchored grep counts before measuring and the restore proven afterwards (blob hash == HEAD blob andgit diff HEADempty):Date.UTC→new Date(y,m,d))setUTCDatealone leaves defect 1 in placesetUTCDate→setDate)Date.UTCalone leaves defect 2 in placeThat green cell is the whole point: the boundary pin is insensitive to defect 2, so the two defects are separable and each pin measures its own.
The mutated file is imported by both pins as a same-package relative specifier (
./memory-analytics.js), and this package'svitest.config.tsaliases only@objectstack/*→ source, so the ablation reaches source; nodistrebuild sits in its path and the "unbuilt ablation stays green" failure mode does not apply. Both ablation legs carried atrap … EXIT INT TERMwith absolute paths — and it earned its keep: a first run died on a shell bug mid-mutation, and the trap left the tree byte-exact (verified by hash).Why UTC, and not "any consistent calendar"
The rest of the platform resolves a bare date to the UTC day:
@objectstack/core's{today}filter-token macro builds its reference day asnew Date(Date.UTC(year, month - 1, day))and falls back to UTC parts when the context carries no timezone, and{TODAY()}in flow templates resolves to the UTC day (#14852 repaired the identical two-calendar shape there — itstemplate-date-offset-dst.test.tsis onorigin/mainand was used as the model for the DST pin's structure). UTC is also the terminal fallback of the engine's own resolution chain,selection.timezone ?? context.timezone ?? 'UTC'(ADR-0053 Phase 2). Before this change the same analytics question asked through this path and through a flow token could select different rows in one deployment — restoring that agreement, not the hour count, is what earns the p2.Scope — spelling fix only
⛔ The parser is not replaced, per triage's ruling on the card: 「先做拼写修复(UTC 化两个缺陷),⛔ 不要顺手换掉整个 parser」. Its vocabulary, its
[range, range]fallback and the shape of the emitted$matchare untouched. Triage asked that anyone proposing a replacement first measure whichrangeforms actually reach it — that measurement was taken and filed rather than acted on:driver-memoryanalytics silently accepts an unparseabledateRangeand matches EVERY row — and the platform's own documented spelling'Last 7 days'is one of them #16041 — an unparseabledateRangeis silently accepted and matches every row (measured through mingo), and the schema's own documented spelling'Last 7 days'is one of the unrecognised forms (startsWith('last ')is case-sensitive).driver-memoryanalytics resolves a stringdateRangewithout ever reading the declaredAnalyticsQuery.timezone#16042 — this path never reads the declaredAnalyticsQuery.timezone; UTC-ising is correct for every query that carries none, but the supplied-timezone case is left exactly as found.Neither is repaired here. Sibling card #15824 (
HistoryCleanupManager) is the same class in a different file and was ⛔ not absorbed.Clause-② — measured
noBuilt at head → swapped only the changed source back to
origin/main(2024eca4f) → rebuilt → diffed the builtdist/index.d.ts→ restored byte-exact and proved it (worktree and index):No exported symbol or signature moves. Separately on the other half of the test — no request is newly accepted or rejected:
parseDateRangeStringis a private method, it enters the same two branches on exactly the same inputs, the fallback is untouched, andAnalyticsQuerySchemais unchanged. What moved is the answer, not the surface. Bump is neverthelessminor, matching how this package levels an answer-changing analytics repair — the act over the commit type.Verification
Gate union re-derived after the final commit with
node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack(provenance line checked: derived from this repo's tree) and run atcb1cb6e54, the head of this branch:check:dual-build-cjs-loadsandcheck:type-check-debtfirst answeredPREREQUISITE NOT MET(exit 3 — the gates' own text says this is ⛔ not a pass and ⛔ not a finding), so the workspace closure was built (turbo run build, 71/71 successful) and both then ran green:✓ check:dual-build-cjs-loads — 103 published require entry point(s) across 66 package(s) loadandcheck-type-check-coverage --re-measure: OK — 12 ledger entr(ies) re-measured in 106.7s, 140 raw tsc error(s) total, none above its recorded number.pnpm --filter @objectstack/driver-memory test— 43 files, 1096 tests, all pass.pnpm --filter @objectstack/driver-memory typecheck— exit 0.--listFilesconfirms both new pins are inside the tsc program (include: src/**/*, no*.test.tsexclusion), so that green is a reading about them and not a vacuous pass.pnpm lint(eslint . --no-inline-config, whole repo) — exit 0, no findings. Run in full rather than narrowed.Every exit code above was captured before any pipe, and each gate's own verdict line is quoted rather than a bare
$?.Notes for review
TZ=UTCasserts nothing — the two spellings are indistinguishable there. Both pin files therefore fake the zone and the instant, and each carries an inline control that evaluates the old spelling and asserts it disagrees, so a green run cannot be confused with "these cells stopped being live".America/St_Johns−03:30,Pacific/Chatham+12:45).TZ=UTCrun is the reference answer, since both spellings coincide there) rather than a re-derived expected value — formonth/yearthere is no offset-free definition, and re-deriving one would just be the fix written twice. Where a definition does exist (day/week) it is asserted as well.🤖 Generated with Claude Code
Generated by Claude Code
Generated by Claude Code